Take Home Exercise 3: Prototyping Modules for Geospatial Analytics Shiny Application

Published

October 27, 2024

Modified

November 3, 2024

1.0 Overview

Demand for Grab’s Ride-hailing services in Jakarta

This project aims to explore the factors affecting demand for Grab’s services in the city of Jakarta through Spatial Interaction Modelling predominantly using Origin-Data Analysis. Our analysis appeals to a diverse set of stakeholders, both for consumers, corporate stakeholders and policy makers especially in Jakarta where traffic congestion is a predominant problem.

Jakarta’s increasing urbanization drives a growing need for on-demand transportation services like Grab, particularly in areas with high concentrations of POIs. However, there is limited understanding of how these points influence traffic congestion across the city. Key questions include:

  • Which POIs generate the most ride-hailing traffic?

  • How does demand vary across different times of the day, week, and seasons?

  • Can ride-hailing services improve access to key locations while alleviating congestion?

Answering these questions will provide insights into Jakarta’s mobility patterns and inform strategies to reduce traffic bottlenecks while maintaining access to key locations.

2.0 Division of Work

My Responsibilities:

  1. EDSA
    • Visualization by Time Clusters

    • Visualization by Weather Patterns (Presence or Absence of Rain)

    • Visualization by Most and Least popular spots for Origin and Destination Trips on the District Level

  2. OD Analysis (District Level)
    • Flow Maps

    • Origin and Destination Flows by Factor

      • Driving Type

      • Time

      • Weather

    • Push & Pull Analysis

3.0 The data

R Packages Used

pacman::p_load(sf, sp, tidyverse, tmap, spatstat, spdep, leaflet, ggthemes, performance, data.table, reshape2, ggpubr, DT, stplanr, kableExtra, RColorBrewer, dplyr,tidyr, gridExtra, circlize)

3.1 Datasets used

Grab Posisi Data (Grab Posisi Data)

  • Contains GPS pings from Grab vehicles, including timestamps, route data, and vehicle type (motorcycle/car).

  • Provides insights into ride-hailing demand, traffic hotspots, and movement patterns around key locations in Jakarta.

Supporting Datasets

  • Jakarta Points of Interest (POI) (HumData): Includes office buildings, shopping malls, parks, and other key locations.

  • Indonesia Population Density Data (ArcGIS): Adds demographic context for understanding mobility trends.

  • Weather API (Weatherbit): Supplements our analysis with weather categories to allow for analysis of Grab Demand according to the presence or absence of rain.

  • Jakarta Map (HDX): Visualize the geographical districts of the City-State.

3.2 Aspatial Data

  1. Category Mapping for Points of Interest
poi_category <- read_csv("data/aspatial/mapping_poi/category_mapping.csv")
Rows: 205 Columns: 2
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (2): value, category

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
  1. Population (Aggregate by District)
jakarta_population_district <- read_csv("data/aspatial/population/jakarta_township_population.csv") %>% 
  group_by(district) %>% 
  summarize(
    population_2019 = sum(population_2019, na.rm=TRUE)
  ) %>% 
  ungroup()
Rows: 264 Columns: 5
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (4): province, city, district, township
dbl (1): population_2019

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
head(jakarta_population_district)
# A tibble: 6 × 2
  district      population_2019
  <chr>                   <dbl>
1 Cakung                  38710
2 Cempaka Putih            7440
3 Cengkareng              40470
4 Cilandak                16880
5 Cilincing               37471
6 Cipayung                21336

We observe that there are 44 Districts within the Area of Jakarta once aggregated to the district level.

  1. Trips Data
  • Inclusive of Origin, Destination

  • Time Cluster

  • Weather Conditions

trips <- readRDS("data/rds/trip_data.rds")
colnames(trips)
 [1] "trj_id"                              "driving_mode"                       
 [3] "origin_time"                         "destination_time"                   
 [5] "total_duration_minutes"              "total_distance_km"                  
 [7] "average_speed_kmh"                   "origin_rawlat"                      
 [9] "origin_rawlng"                       "destination_rawlat"                 
[11] "destination_rawlng"                  "origin_lat"                         
[13] "origin_lng"                          "destination_lat"                    
[15] "destination_lng"                     "origin_province"                    
[17] "origin_city"                         "origin_district"                    
[19] "destination_province"                "destination_city"                   
[21] "destination_district"                "origin_datetime"                    
[23] "destination_datetime"                "origin_day"                         
[25] "origin_hour"                         "destination_day"                    
[27] "destination_hour"                    "origin_time_cluster"                
[29] "destination_time_cluster"            "origin_date"                        
[31] "origin_weather_description"          "origin_weather_description_category"
origin_trips <- st_as_sf(trips, coords = c("origin_lng", "origin_lat"), crs=4326)
destination_trips <- st_as_sf(trips, coords = c("destination_lng", "destination_lat"), crs=4326)

3.3 Geospatial Data

  1. Read the Indonesia Administrative boundary shapefile
# Step 1: Read the Indonesia administrative boundary shapefile
indonesia <- st_read(
  dsn = "data/geospatial/indo_map", 
  layer = "idn_admbnda_adm3_bps_20200401"
)
Reading layer `idn_admbnda_adm3_bps_20200401' from data source 
  `/Users/jezelei/jezeleii/IS415-GA/Take-Home_Exercise/TakeHomeEx03/data/geospatial/indo_map' 
  using driver `ESRI Shapefile'
Simple feature collection with 7069 features and 16 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: 95.01079 ymin: -11.00762 xmax: 141.0194 ymax: 6.07693
Geodetic CRS:  WGS 84
  1. Filter for DKI Jakarta and rename it to “Jakarta”
  2. Exclude districts belonging to “Kepulauan Seribu”
jakarta_district <- indonesia %>%
  filter(ADM1_EN == "Dki Jakarta") %>%
  mutate(ADM1_EN = "Jakarta")

jakarta_district <- jakarta_district %>%
  filter(ADM2_EN != "Kepulauan Seribu")  # Exclude Kepulauan Seribu
  1. We filter down the required columns and rename accordingly
jakarta_district <- jakarta_district %>%
  dplyr::select(ADM1_EN, ADM2_EN, ADM3_EN, geometry)

jakarta_district <- jakarta_district %>%
  rename(
    province = ADM1_EN,
    city = ADM2_EN,
    district = ADM3_EN,
  )
  1. We change the CRS to 5580, Indonesia’s CRS
  2. We simplify the geometry with a smaller tolerance to reduce subsequent compute load
jakarta_district <- jakarta_district %>%
  st_transform(crs = 5580)  # Transform to WGS84

jakarta_district <- jakarta_district %>%
  mutate(across(where(is.character), tolower))
jakarta_district_df <- jakarta_district %>%
  st_drop_geometry()

jakarta_district <- jakarta_district %>%
  st_simplify(dTolerance = 10.0)  # Smaller tolerance for longitude/latitude data
  1. We plot the interactive map using tmap
# Step 8: Plot the interactive map using tmap
tmap_mode("view")  
tmap mode set to interactive viewing
tm_shape(jakarta_district) +  
  tm_polygons("district", palette = "Blues", 
              border.col = "black", lwd = 0.5) +  # Display district polygons with labels
  tm_basemap("OpenStreetMap") 
Warning: Number of levels of the variable "district" is 44, which is larger
than max.categories (which is 30), so levels are combined. Set
tmap_options(max.categories = 44) in the layer function to show all levels.
tmap_mode('plot')
tmap mode set to plotting

Calculation of Centroid:

jakarta_district_centroid <- jakarta_district %>%
  mutate(
    centroid = st_centroid(geometry),
    centroid_lat = st_coordinates(centroid)[, 2],
    centroid_lng = st_coordinates(centroid)[, 1]
 )

jakarta_district_centroid$district <- str_to_title(jakarta_district_centroid$district)
tmap_mode('view')
tmap mode set to interactive viewing
tm_shape(jakarta_district) + 
  tm_polygons(alpha=0.3) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.1, alpha= 0.5, popup.vars=c("District" = "district"))
tmap_mode('plot')
tmap mode set to plotting
  1. Points of Interest Locations
indonesia_poi <- st_read(dsn="data/geospatial/indo_poi", layer="hotosm_idn_points_of_interest_points_shp") %>% st_transform(crs = 5580) 
Reading layer `hotosm_idn_points_of_interest_points_shp' from data source 
  `/Users/jezelei/jezeleii/IS415-GA/Take-Home_Exercise/TakeHomeEx03/data/geospatial/indo_poi' 
  using driver `ESRI Shapefile'
Simple feature collection with 150315 features and 17 fields
Geometry type: POINT
Dimension:     XY
Bounding box:  xmin: 95.04186 ymin: -11.00919 xmax: 141.0188 ymax: 6.073289
Geodetic CRS:  WGS 84
jakarta_poi <- st_intersection(indonesia_poi, jakarta_district)
Warning: attribute variables are assumed to be spatially constant throughout
all geometries
tmap_mode('plot')
tmap mode set to plotting
tm_shape(jakarta_district) + 
  tm_polygons() + 
tm_shape(jakarta_poi) + 
  tm_dots( alpha = 0.2)

4.0 Processing OD Data

For the purposes of this Take-Home Exercise, we will be experimenting with a subset of data, confining the trips dataset to a:

  • certain day of week [Friday]

This filtered data will be stored as od_trips_friday for subsequent analysis. After which, we will zoom in on different levels based on the following variables:

  1. Weather [rain or not_rain]
  2. Vehicle Type [car or motorcycle]
  3. Points of Interest within the District [Categorical Count]
  4. Time Clusters

4.1 Geospatial Wrangling - Combining Trips Data with Centroid Mapping

  1. Filter trips for Friday
od_trips_friday <- trips %>%
  filter(origin_day == "friday")
  1. Convert Origin to SF Object
  2. Convert Destination to SF object
origin_sf <- od_trips_friday %>%
  st_as_sf(coords = c("origin_lng", "origin_lat"), crs = 5580)

destination_sf <- od_trips_friday %>%
  st_as_sf(coords = c("destination_lng", "destination_lat"), crs = 5580)
  1. Transform jakarta_district_centroid to match CRS
  2. Perform spatial join for origin and destination points to match with the centroid
  3. Remove geometries to perform a left_join as opposed to an st_join
jakarta_district_centroid <- jakarta_district_centroid %>%
  st_transform(crs = 5580)


origin_with_centroids <- st_join(origin_sf, jakarta_district_centroid, left = FALSE) %>%
  select(trj_id, origin_district, driving_mode, origin_time_cluster, 
         origin_weather_description_category, centroid_lat_origin = centroid_lat, 
         centroid_lng_origin = centroid_lng)


destination_with_centroids <- st_join(destination_sf, jakarta_district_centroid, left = FALSE) %>%
  select(trj_id, destination_district, centroid_lat_destination = centroid_lat, 
         centroid_lng_destination = centroid_lng)
  1. Merge origin and destination data on trj_id
origin_with_centroids_df <- origin_with_centroids %>% st_set_geometry(NULL)
destination_with_centroids_df <- destination_with_centroids %>% st_set_geometry(NULL)


od_merged <- left_join(origin_with_centroids_df, destination_with_centroids_df, by = "trj_id")
Warning in left_join(origin_with_centroids_df, destination_with_centroids_df, : Detected an unexpected many-to-many relationship between `x` and `y`.
ℹ Row 1948 of `x` matches multiple rows in `y`.
ℹ Row 2720 of `y` matches multiple rows in `x`.
ℹ If a many-to-many relationship is expected, set `relationship =
  "many-to-many"` to silence this warning.
od_merged <- od_merged %>%
  select(trj_id, origin_district, destination_district, driving_mode, origin_time_cluster, 
         origin_weather_description_category, centroid_lat_origin, centroid_lng_origin, 
         centroid_lat_destination, centroid_lng_destination)

head(od_merged)
# A tibble: 6 × 10
  trj_id origin_district   destination_district driving_mode origin_time_cluster
  <chr>  <chr>             <chr>                <chr>        <chr>              
1 10003  kelapa gading     pasar rebo           car          morning lull       
2 10043  setia budi        pasar minggu         car          morning peak       
3 10061  makasar           duren sawit          car          morning peak       
4 10072  setia budi        mampang prapatan     car          morning peak       
5 10086  kebayoran lama    pasar minggu         motorcycle   morning lull       
6 10089  grogol petamburan tanah abang          car          midnight lull      
# ℹ 5 more variables: origin_weather_description_category <chr>,
#   centroid_lat_origin <dbl>, centroid_lng_origin <dbl>,
#   centroid_lat_destination <dbl>, centroid_lng_destination <dbl>

Next, we process the data by filtering out records with undefined destination districts, removing intra-zonal trips and removing duplicate or NA values:

Filtering Out undefined Origin Destination Districs (this could arise because of unmatched districts where there are trips coming to and fro outside of Jakarta). We convert it to lowercase beforehand first.

district_ids <- str_to_lower(str_trim(unique(jakarta_district_centroid$district)))

# Convert `origin_district` and `destination_district` to lowercase and trim whitespace in `od_trips_friday`
od_trips_friday <- od_trips_friday %>%
  mutate(
    origin_district = str_to_lower(str_trim(origin_district)),
    destination_district = str_to_lower(str_trim(destination_district))
  )

# Re-run the unmatched districts check
unmatched_origins <- od_trips_friday %>%
  filter(!origin_district %in% district_ids) %>%
  distinct(origin_district)

unmatched_destinations <- od_trips_friday %>%
  filter(!destination_district %in% district_ids) %>%
  distinct(destination_district)

# Display unmatched IDs
unmatched_origins
# A tibble: 1 × 1
  origin_district   
  <chr>             
1 outside of jakarta
unmatched_destinations
# A tibble: 1 × 1
  destination_district
  <chr>               
1 outside of jakarta  

In the next step, we check for duplicate records as well:

duplicate <- od_merged %>% 
  group_by_all() %>% 
  filter(n() > 1) %>% 
  ungroup()
duplicate
# A tibble: 0 × 10
# ℹ 10 variables: trj_id <chr>, origin_district <chr>,
#   destination_district <chr>, driving_mode <chr>, origin_time_cluster <chr>,
#   origin_weather_description_category <chr>, centroid_lat_origin <dbl>,
#   centroid_lng_origin <dbl>, centroid_lat_destination <dbl>,
#   centroid_lng_destination <dbl>

Since the result is 0, we can proceed with creation of desire line maps after removing the points where there are invalid destination points (which likely correspond with outside_of_jakarta districts)

od_merged<- od_merged %>% 
  drop_na(destination_district)

head(od_merged)
# A tibble: 6 × 10
  trj_id origin_district   destination_district driving_mode origin_time_cluster
  <chr>  <chr>             <chr>                <chr>        <chr>              
1 10003  kelapa gading     pasar rebo           car          morning lull       
2 10043  setia budi        pasar minggu         car          morning peak       
3 10061  makasar           duren sawit          car          morning peak       
4 10072  setia budi        mampang prapatan     car          morning peak       
5 10086  kebayoran lama    pasar minggu         motorcycle   morning lull       
6 10089  grogol petamburan tanah abang          car          midnight lull      
# ℹ 5 more variables: origin_weather_description_category <chr>,
#   centroid_lat_origin <dbl>, centroid_lng_origin <dbl>,
#   centroid_lat_destination <dbl>, centroid_lng_destination <dbl>

5.0 Subset of Prototype: Visualizing Spatial Interaction

5.1 Removing Intra-zonal flows

We filter out rows where origin_district is the same as the destination_district.

od_data <- od_merged %>% 
  filter(origin_district != destination_district)
  1. We ensures the district names are lowercased before proceeding to
od_data <- od_data %>%
  mutate(origin_district = tolower(origin_district),
         destination_district = tolower(destination_district)) %>% 
  select(origin_district, destination_district, origin_time_cluster, origin_weather_description_category, everything())

jakarta_district_centroid <- jakarta_district_centroid %>%
  mutate(district = tolower(district))
  1. We create origin and destination points with explicit IDs
origin_points <- od_data %>%
  select(origin_district, centroid_lat_origin, centroid_lng_origin) %>%
  distinct() %>%
  st_as_sf(coords = c("centroid_lng_origin", "centroid_lat_origin"), crs = 5580) %>%
  rename(district = origin_district)

destination_points <- od_data %>%
  select(destination_district, centroid_lat_destination, centroid_lng_destination) %>%
  distinct() %>%
  st_as_sf(coords = c("centroid_lng_destination", "centroid_lat_destination"), crs = 5580) %>%
  rename(district = destination_district)

5.2 Creating Desire Lines

desire_lines <- od2line(
  flow = od_data,
  zones = origin_points,
  destination = destination_points
)
colnames(od_data)
 [1] "origin_district"                     "destination_district"               
 [3] "origin_time_cluster"                 "origin_weather_description_category"
 [5] "trj_id"                              "driving_mode"                       
 [7] "centroid_lat_origin"                 "centroid_lng_origin"                
 [9] "centroid_lat_destination"            "centroid_lng_destination"           
od_aggregated <- od_data %>%
  group_by(origin_district, destination_district) %>%
  summarise(trip_count = n_distinct(trj_id), .groups = "drop")

# View the result
head(od_aggregated)
# A tibble: 6 × 3
  origin_district destination_district trip_count
  <chr>           <chr>                     <int>
1 cakung          cempaka putih                 2
2 cakung          cipayung                      2
3 cakung          duren sawit                   5
4 cakung          gambir                        1
5 cakung          jatinegara                    5
6 cakung          johar baru                    1

We save the output in rds format for future use:

write_rds(od_data, "data/rds/od_data.rds")

We import and save the rds into our R environment:

od_data <- read_rds ("data/rds/od_data.rds")

5.2 Visualizing Desire Lines by Time Cluster

We first create a BASEMAP FOR FRIDAY TRIPS

desire_lines_filtered <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(trip_count > 5)

tmap_mode('view')
tmap mode set to interactive viewing
tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.3) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.1, 
          alpha = 0.5, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_filtered) +
  tm_lines(
    col = "trip_count",          
    palette = "viridis",         
    lwd = 1,                    
    alpha = 0.5,                
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_layout(legend.outside = TRUE)
tmap_mode('plot')
tmap mode set to plotting

For the purposes of dynamic visualization, we avoid sticking with light default colours of tmap and an alternative propsed as seen from above is using the viridis palette.

From here, we can visualize the desire line maps based on the different variables mentioned earlier : 1. Time Cluster 2. Weather Category 3. Vehicle Type

TIME CLUSTER

colnames(od_data)
 [1] "origin_district"                     "destination_district"               
 [3] "origin_time_cluster"                 "origin_weather_description_category"
 [5] "trj_id"                              "driving_mode"                       
 [7] "centroid_lat_origin"                 "centroid_lng_origin"                
 [9] "centroid_lat_destination"            "centroid_lng_destination"           
desire_lines_mid_lull <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "midnight lull") %>% 
  filter(trip_count > 10)

desire_lines_mid_peak <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "midnight peak") %>% 
  filter(trip_count > 10)

desire_lines_morn_lull <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "morning lull") %>% 
  filter(trip_count > 10)

desire_lines_morn_peak <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "morning peak") %>% 
  filter(trip_count > 10)

desire_lines_aft_lull <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "afternoon lull") %>% 
  filter(trip_count > 10)

desire_lines_aft_peak <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "afternoon peak") %>% 
  filter(trip_count > 10)

desire_lines_evn_lull <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "evening lull") %>% 
  filter(trip_count > 10)

desire_lines_evn_peak <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_time_cluster == "evening peak") %>% 
  filter(trip_count > 10)

tmap_mode('plot')
tmap mode set to plotting
tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_mid_lull) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(1,2,5,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("OpenStreetMap") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Midnight Lull", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_mid_peak) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(1,2,5,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("OpenStreetMap") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Midnight Peak", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_morn_lull) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(1,2,5,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("OpenStreetMap") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Midnight Lull", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_morn_peak) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(1,2,5,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("Esri.WorldGrayCanvas") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Morning Peak", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_aft_lull) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(1,2,5,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("OpenStreetMap") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Afternoon Lull", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_aft_lull) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(1,2,5,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("OpenStreetMap") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Afternoon Peak", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_evn_lull) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(1,2,5,7,8,9,10,11,12),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("OpenStreetMap") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Evening Lull", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)
Legend labels were too wide. Therefore, legend.text.size has been set to 0.71. Increase legend.width (argument of tm_layout) to make the legend wider and therefore the labels larger.

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.5) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.3, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_evn_peak) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(0.5,1,3,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("OpenStreetMap") + 
  tm_layout(legend.outside = FALSE, 
            main.title="Desire Lines for Grab Trips at Evening Peak", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

5.3 Visualizing Desire Lines by Weather Category

For urban planners, policy makers and corporate stakeholders like Grab which rely on weather data to determine prices (where we hypothesize that strong rain for example will lead to higher demand of Grab vehicles)

From here, we can visualize the desire line maps based on the different variables mentioned earlier : 1. Time Cluster 2. Weather Category 3. Vehicle Type

WEATHER CATEGORY

desire_lines_not_rain <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_weather_description_category == "not_rain") %>%   filter(trip_count > 10)

desire_lines_rain <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(origin_weather_description_category == "rain") %>% 
  filter(trip_count > 10)
tmap_mode('view')
tmap mode set to interactive viewing
tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.2) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.1, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_not_rain) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(0.5,1,3,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("CartoDB.Positron") + 
  tm_layout(legend.outside = TRUE, 
            main.title="Desire Lines for No Rain Conditions", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
   tm_view(view.legend.position = c("RIGHT", "BOTTOM"))
Legend for line widths not available in view mode.
tmap_mode('plot')
tmap mode set to plotting

Note: In our data processing, we aggregated weather conditions into rain and no rain for the purposes of simplification for the exercise and to optimize data processing.

tmap_mode('view')
tmap mode set to interactive viewing
tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.2) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.1, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_rain) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(0.5,1,3,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("CartoDB.Positron") + 
  tm_layout(legend.outside = TRUE, 
            main.title="Desire Lines for Rain Conditions", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) 
Legend for line widths not available in view mode.
tmap_mode('plot')
tmap mode set to plotting

5.3.1 Graphical Distribution of Weather Conditions throughout the Time Clusters:

Besides Map interactivity, a useful visualization for new and expert users alike are accompanying bar charts to show the weather distribution for the selected day of week, in this case Fridays.

The code chunk below 1. We summarize the origin_time_cluster and origin_weather_description category 2. Plot accordingly in a comparative bar graph

rain_distribution <- od_merged %>%
  group_by(origin_time_cluster, origin_weather_description_category) %>%
  summarize(count = n(), .groups = "drop") %>%
  ungroup() %>%
 
  complete(origin_time_cluster, origin_weather_description_category, fill = list(count = 0))
ggplot(rain_distribution, aes(y = origin_time_cluster, x = count, fill = origin_weather_description_category)) +
  geom_bar(stat = "identity", position = "dodge") +
  labs(
    title = "Distribution of Rain and Not Rain Conditions by Time Cluster",
    y = "Time Cluster",
    x = "Count",
    fill = "Weather Condition"
  ) +
  theme_minimal() +
  theme(axis.text.y = element_text(angle = 0, hjust = 1))

5.4 Visualizing Desire Lines by Vehicle Type

Upon further inspection of the data, a key perspective we can investigate is the distribution of trips or how vehicle type affects desire lines and demand for Grab Vehicles.

Given Indonesia’s unique context of Traffic Congestion where to curb this, it has implemented legislation such as the Odd-Even traffic policy(TL;DR: License plates ending in odd-numbers can only drive in odd-numbered days, and the same goes for even numbers). This policy was eventually exempted for Ride-hailing services such as Grab and Gojek, but such policies shed light on the dire state of traffic congestion within the country.

There is a general understanding that motorcycle taxis in traffic-congested areas within Indonesia, especially its capital, Jakarta, are much faster on the road given they can pass through narrow roads and is much more accessible in terms of price for the average Indonesian. Given its context of one of the most traffic-congested countries in the world, the prioritization of convenience and accessibility has made the use of motorbikes more prevalent in Indonesia, as compared to other countries such as Singapore where reliable public transportation may offset such demand.

The code chunk below shows the distribution of trips by vehicle type ‘cars’ and ‘motorcycle’ respectively

desire_lines_car <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(driving_mode == "car") %>% 
  filter(trip_count > 10)

desire_lines_motorcycle <- desire_lines %>%
  left_join(od_aggregated, by = c("origin_district", "destination_district")) %>% 
  filter(driving_mode == "motorcycle") %>% 
  filter(trip_count > 10)


tmap_mode('plot')
tmap mode set to plotting
tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.2) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.1, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_car) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(0.5,1,3,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("CartoDB.Positron") + 
  tm_layout(legend.outside = TRUE, 
            main.title="Desire Lines for Driving Mode: Car", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

tm_shape(jakarta_district) + 
  tm_polygons(alpha = 0.2) + 
  tm_shape(jakarta_district_centroid) + 
  tm_dots(size = 0.1, 
          alpha = 0.6, 
          popup.vars = c("District" = "district")) +
  tm_shape(desire_lines_motorcycle) +
  tm_lines(
    lwd = "trip_count",       
    scale = c(0.5,1,3,7),              
    alpha = 0.5,
    popup.vars = c("origin_district", "destination_district", "trip_count")
  ) +
  tm_basemap("CartoDB.Positron") + 
  tm_layout(legend.outside = TRUE, 
            main.title="Desire Lines for Driving Mode: Motorcycle", 
            main.title.position = "center", 
            main.title.size = 0.6, 
            frame=TRUE) + 
  tm_legend(position = c("RIGHT", "BOTTOM"), legend.text.size =0.8)

5.5 Visualizing Distribution of POIs

Here, we can aggregate the counts of the POIs according to their categories based on their geographic distribution. We can use the centroid of the district we have calculated earlier to combine trips and jakarta_district data

  1. We perform spatial intersection to associate POIs with Jakarta Districts
  2. Join poi_category to get the category column
  3. Aggregate by district and category

We join the poi_category table with the jakarta_poi dataset to map the columns accordingly:

poi_jakarta_district <- st_intersection(jakarta_poi, jakarta_district) %>% 
  select(district, value = amenity) %>% 
  st_drop_geometry() 
Warning: attribute variables are assumed to be spatially constant throughout
all geometries
poi_category_jakarta <- poi_jakarta_district %>% 
  left_join(poi_category, by = "value")

poi_aggregated <- poi_category_jakarta %>% 
  group_by(district, category) %>% 
  summarise(count = n(), .groups='drop') %>% 
  filter(!is.na(category))

datatable(poi_aggregated)

We can visualize the distribution of the POIs

poi_aggregated_wide <- poi_aggregated %>%
  pivot_wider(
    names_from = category, 
    values_from = count, 
    values_fill = 0  # Fill missing values with 0
  )

jakarta_district_counts <- jakarta_district %>%
  left_join(poi_aggregated_wide, by = "district")

category_maps <- names(poi_aggregated_wide)[-1]  # Exclude 'district' column
map_list <- lapply(category_maps, function(category) {
  tm_shape(jakarta_district_counts) +
    tm_polygons(
      col = category, 
      palette = "Blues", 
      title = paste(category),
      style = "quantile"
    ) +
    tm_layout(main.title = paste(category), main.title.size = 1.2)
})

We establish common breaks to standardize the legend values across the different maps as opposed to taking the automatic legend values based on quantiles per map. This is to facilitate ease of comparison and visualization for the User.

common_breaks <- c(0, 20, 40, 60, 80, 100, max(poi_aggregated_wide[-1], na.rm = TRUE))


map_list <- lapply(category_maps, function(category) {
  tm_shape(jakarta_district_counts) +
    tm_polygons(
      col = category, 
      palette = "Blues", 
      title = paste(category),
      breaks = common_breaks  
    ) +
    tm_layout(main.title = paste(category), main.title.size = 1.2)
})

tmap_arrange(map_list[[1]], map_list[[2]], map_list[[3]], map_list[[4]], ncol = 2, nrow = 2)

tmap_arrange(map_list[[5]], map_list[[6]], map_list[[7]], map_list[[8]], ncol = 2, nrow = 2)

We can also visualize it using the tooltip of each district’s centroid we have calculated earlier. We join the tables for jakarta_district_centroid and the POI mapping and append a tooltip column

colnames(jakarta_district_centroid)
[1] "province"     "city"         "district"     "geometry"     "centroid"    
[6] "centroid_lat" "centroid_lng"
unique(poi_category$category)
[1] "Facilities_Services"      "Essentials"              
[3] "Offices_Business"         "Cultural_Attractions"    
[5] "Restaurants_Food"         "Recreation_Entertainment"
[7] "Others"                   "Shops"                   
[9] "Tourism_Spots"           
  1. Ensure district columns are in lowercase in both dataframes
poi_aggregated <- poi_aggregated %>%
  mutate(district = tolower(district))

jakarta_district_centroid <- jakarta_district_centroid %>%
  mutate(district = tolower(district))
  1. Pivot poi_aggregated to create a separate column for each category
tooltip_data <- poi_aggregated %>%
  pivot_wider(
    names_from = category,
    values_from = count,
    values_fill = list(count = 0)  # Fill missing values with 0
  )
  1. Ensure the columns are within tooltip_data, for values with NA (categories that are not present for that district), we will with 0
required_categories <- c(
  "Facilities_Services", "Restaurants_Food", "Essentials", 
  "Offices_Business", "Cultural_Attractions", "Recreation_Entertainment",
  "Shops", "Tourism_Spots", "Others"
)

for (category in required_categories) {
  if (!category %in% colnames(tooltip_data)) {
    tooltip_data[[category]] <- 0
  }
}

jakarta_district_centroid_expanded <- jakarta_district_centroid %>%
  left_join(tooltip_data, by = "district")
  1. We plot the map
tmap_mode("view")
tmap mode set to interactive viewing
tm_shape(jakarta_district) +  
  tm_polygons(alpha = 0.3, border.col = "black") +  # District boundaries
  tm_shape(jakarta_district_centroid_expanded) + 
  tm_dots(
    size = 0.1, col = "blue", alpha = 0.5,
    popup.vars = c(
      "District" = "district",
      "Facilities_Services" = "Facilities_Services",
      "Restaurants_Food" = "Restaurants_Food",
      "Essentials" = "Essentials",
      "Offices_Business" = "Offices_Business",
      "Cultural_Attractions" = "Cultural_Attractions",
      "Recreation_Entertainment" = "Recreation_Entertainment", 
      "Shops" = "Shops", 
      "Tourism_Spots" = "Tourism_Spots",
      "Others" = "Others"
    )
  ) +
  tm_basemap("OpenStreetMap")
tmap_mode("plot")
tmap mode set to plotting

Now that we can see the number of POI categories per district by clicking into the centroid, we can also visualize this distribution through exploring the top 10 districts with the most POI to potentially use for subsequent analysis:

  1. We obtain the top_districts by summing up the count across all categories per district
  2. We filter to the top 5 districts
  3. Arrange the Category count by descending order per district (using )
  4. Create a Grouped Bar Chart
top_districts <- poi_aggregated %>%
  group_by(district) %>%
  summarise(total_count = sum(count)) %>%
  arrange(desc(total_count)) %>%
  slice(1:5) %>%
  pull(district)


poi_aggregated_top <- poi_aggregated %>%
  filter(district %in% top_districts)


spacing_rows <- poi_aggregated_top %>%
  distinct(district) %>%
  mutate(category = NA, count = NA)


poi_aggregated_with_spacing <- bind_rows(poi_aggregated_top, spacing_rows) %>%
  arrange(district)
#create a grouped bar chart 
ggplot(poi_aggregated_with_spacing, aes(y = district, x = count, fill = category)) +
  geom_bar(stat = "identity", position = "dodge", na.rm = TRUE) +
  labs(
    title = "Distribution of POI Categories by District",
    x = "Count",
    y = "District",
    fill = "Category"
  ) +
  theme_minimal() +
  theme(
    axis.text.y = element_text(angle = 0, hjust = 1),
    legend.position = "bottom",
    plot.margin = margin(10, 10, 10, 10)
  ) +
  scale_fill_brewer(palette = "Set3")

5.6 Prototype Samples for Shiny App

After exploring the various different variables we can filter with, this would be our sample interface by filtering with the above variables for OD Analysis - Day of Week - Time Cluster - Weather Conditions - Vehicle Type.

With this as our base template:

6.0 Spatial Interaction Modelling

Aside from the graphical representation of the desire lines with respect to the selected variables, we will also explore the implementation of interaction models of OD data of the Grab Trips.

We will be exploring SIM to determine factors affecting Grab Vehicle demand flows during Fridays in the our Grab Dataset which spans 2 weeks in Jakarta.

We will be exploring

  1. Origin Constrained Model
  2. Destination Constrained Model
  3. Doubly Constrained Model
head(poi_aggregated)
# A tibble: 6 × 3
  district category             count
  <chr>    <chr>                <int>
1 cakung   Cultural_Attractions     2
2 cakung   Essentials              12
3 cakung   Facilities_Services     22
4 cakung   Offices_Business        18
5 cakung   Others                   1
6 cakung   Restaurants_Food         3

6.1 Computing Distance Matrix

The computed distance matrix shows the distance between pairs of locations, in this case the distance between origin district and destination districts of each trajectory of the trips. The diagonal of the table will demonstrate that a location’s distance from itself (e.g origin district distance from origin distrct) will be 0.

We will use the jakarta_district_centroid_expanded to compute the distance of each district from each other

jakarta_dist_centroid_sp <- as(jakarta_district_centroid_expanded$centroid, "Spatial")
jakarta_dist_centroid_sp
class       : SpatialPoints 
features    : 44 
extent      : 13484362, 13585052, -3018643, -2900311  (xmin, xmax, ymin, ymax)
crs         : +proj=tmerc +lat_0=0 +lon_0=30 +k=1 +x_0=300000 +y_0=0 +ellps=krass +units=m +no_defs 

For the purposes of optimizing data processing for our final Shiny application, we will process using sp method as compared to sf, due to less compute power generally needed for sp functions.

We use spDists() of sp package to compute the distance between centroids of the Jakarta Districts

dist_districts <- spDists(jakarta_dist_centroid_sp, longlat=FALSE)
head(dist_districts, n=c(5,5))
         [,1]     [,2]     [,3]      [,4]      [,5]
[1,]     0.00 33262.49 94446.12  81967.21  28291.84
[2,] 33262.49     0.00 61294.34  61065.20  42488.85
[3,] 94446.12 61294.34     0.00  68589.05  95302.01
[4,] 81967.21 61065.20 68589.05      0.00 101922.70
[5,] 28291.84 42488.85 95302.01 101922.70      0.00

For ease of visualization, we can use the kableExtra package to plot a highlight table for the distance matrix

Before which, we convert it into a data frame and label the rows and columns with district names

dist_districts_df <- as.data.frame(dist_districts)

rownames(dist_districts) <- jakarta_district_centroid_expanded$district
colnames(dist_districts) <- jakarta_district_centroid_expanded$district

At this juncture, we represent the distance matrix calculated to display using the Highlight table of distances:

In our final prototype, we also consider cognitive load and visual comprehension so we will mock some changes we might implement, such as rounding to the nearest integer, using a sequential colour scale (where higher distance values represent longer distances as well as ease of reading the data)

  1. Round the Distance to the nearest integer
rounded_distances <- dist_districts_df[1:44, 1:44] %>%
  mutate(across(everything(), round))
  1. Define a sequential color palette (using RBrewer)
color_palette <- colorRampPalette(brewer.pal(9, "Blues"))(100)
  1. Apply the color palette to the distance values. As values above 70k can become dark, we will conditionally make it into a white font
highlighted_distances <- rounded_distances %>%
  mutate(across(everything(),
                ~ cell_spec(., 
                            color = ifelse(. > 90000, "white", "black"),
                            background = spec_color(as.numeric(.),
                                                    option = "custom",
                                                    scale_from = range(rounded_distances, na.rm = TRUE),
                                                    palette = color_palette),
                            bold = TRUE)))
  1. We create the Highlight table
dist_matrix_highlight <- kable(highlighted_distances, escape = FALSE, format = "html") %>%
  kable_styling("striped", full_width = TRUE) %>%
  add_header_above(c(" " = 1, "Distance Matrix" = 43))
dist_matrix_highlight
Distance Matrix
V1 V2 V3 V4 V5 V6 V7 V8 V9 V10 V11 V12 V13 V14 V15 V16 V17 V18 V19 V20 V21 V22 V23 V24 V25 V26 V27 V28 V29 V30 V31 V32 V33 V34 V35 V36 V37 V38 V39 V40 V41 V42 V43 V44
0 33262 94446 81967 28292 64522 71799 41186 46710 21978 56386 71165 85196 33815 39344 110332 68270 78067 78093 21166 40388 90523 34965 52479 41945 61664 35340 48144 54441 66063 53535 68673 73567 85634 87349 23632 51485 43230 52353 58752 65331 60279 39679 44837
33262 0 61294 61065 42489 67665 69558 14199 17160 33560 23163 37989 72555 21660 6187 77380 41951 50687 45205 18635 9920 57913 32082 43320 40315 38883 10482 16443 27423 33357 35603 52897 66571 54358 58887 11079 20098 10233 24744 27118 33178 28989 22603 23202
94446 61294 0 68589 95302 109158 104370 55554 49643 91374 38144 23305 90985 72626 55419 16643 51240 48269 22466 76241 54588 21028 78107 81172 87355 59384 62745 49051 47031 32294 67487 75538 96151 20645 45601 71876 44048 51852 52305 37269 30531 40980 63767 62486
81967 61065 68589 0 101923 52073 42693 70717 68651 63941 56096 58637 22807 48268 57361 82192 22018 20443 47983 78175 65452 48598 93131 33577 45999 22185 51750 47868 76817 45737 28445 16326 32663 80022 24670 62653 65557 54665 37047 66467 66034 40162 81983 38946
28292 42489 95302 101923 0 92357 98806 40571 46237 49397 60903 73727 109298 55043 47940 109216 84218 93154 84638 24028 43390 97610 18025 76833 68013 80001 50378 58713 48779 73805 74230 91027 99382 81082 101343 39341 52004 51834 66998 58186 64820 71057 31603 62977
64522 67665 109158 52073 92357 0 12031 81827 83690 42970 79964 90718 37017 46030 68824 125149 58997 65677 86716 74980 77036 93191 93129 28247 27383 50022 58114 66548 94176 77968 41821 37138 21178 112833 73858 60456 84279 69277 58512 89326 92784 69203 89093 49975
71799 69558 104370 42693 98806 12031 0 83384 84422 49827 78503 87715 25368 48175 69650 119848 53240 58564 81960 79501 78274 87007 97553 26720 31087 45104 59368 65541 94737 74472 38829 29504 10174 110152 65923 63969 84187 69397 56186 88460 91116 65987 91820 49166
41186 14199 55554 70717 40571 81827 83384 0 5919 46632 20574 33261 84596 35799 14182 70515 49784 57102 44243 21053 5402 57180 25307 56888 54500 48996 24017 22888 13961 34044 47796 64729 79863 44532 63929 23685 11568 16102 33713 18379 25167 33315 11624 35487
46710 17160 49643 68651 46237 83690 84422 5919 0 50596 15650 27495 83848 37859 14877 64610 47172 53795 38978 26905 7270 51840 30335 57729 56598 47472 25591 21035 10501 29390 47540 63989 80226 38924 60048 27729 5768 15268 32115 12465 19251 29854 15967 35608
21978 33560 91374 63941 49397 42970 49827 46632 50596 0 54318 68662 64179 19806 37840 107956 54456 64308 71632 33656 43330 82693 51405 31960 20403 46187 28729 42323 60513 59825 36481 49193 51807 87367 73985 22960 53565 40508 41691 60371 65966 52100 50451 31842
56386 23163 38144 56096 60903 79964 78503 20574 15650 54318 0 14843 73712 37120 17294 54345 34097 39364 23737 39294 17675 36707 45810 52182 54482 36699 25697 13517 21089 13766 39801 54324 72720 33741 44844 33779 10580 13920 22511 10544 12833 15961 31595 29995
71165 37989 23305 58637 73727 90718 87715 33261 27495 68662 14843 0 78958 50583 32131 39545 37484 39099 14216 53399 31625 25700 57379 62485 66752 43230 39933 26589 27838 13493 49125 60876 80734 22482 41409 48607 21731 28640 32240 16026 10603 21729 42814 41657
85196 72555 90985 22807 109298 37017 25368 84596 83848 64179 73712 78958 0 54400 70459 104925 41491 42729 69705 87002 79194 71318 103904 32762 43823 37019 62190 62941 93190 65562 36973 19879 15968 101033 47438 70716 81897 68765 51962 84253 85039 58510 94947 49404
33815 21660 72626 48268 55043 46030 48175 35799 37859 19806 37120 50583 54400 0 23133 89268 35379 45290 52169 32717 31045 62952 50185 22725 18758 27994 12357 24019 48359 40594 19838 36035 46111 70847 54870 16465 39107 24356 21898 44994 49629 32529 43651 12138
39344 6187 55419 57361 47940 68824 69650 14182 14877 37840 17294 32131 70459 23133 0 71633 37306 45600 39018 24408 8815 51734 36134 43010 41808 35339 10785 10790 25353 27183 33617 50609 65791 49537 53459 16489 16134 4074 20403 22593 28156 23162 24756 21313
110332 77380 16643 82192 109216 125149 119848 70515 64610 107956 54345 39545 104925 89268 71633 0 66612 62399 38550 91482 70120 33607 91560 97347 103877 75175 79286 65642 60443 48902 83680 90657 111280 29146 58038 88122 59245 68185 68860 52149 45375 57533 77642 79082
68270 41951 51240 22018 84218 58997 53240 49784 47172 54456 34097 37484 41491 35379 37306 66612 0 9911 29013 60191 44712 34196 73438 32362 41945 9492 34213 26991 54930 24172 19764 24342 44920 59569 19538 46166 43741 34083 17232 44449 44184 18148 61298 23521
78067 50687 48269 20443 93154 65677 58564 57102 53795 64308 39364 39099 42729 45290 45600 62399 9911 0 27614 69177 52310 28884 81516 40570 51025 18573 43633 34912 60446 27091 28967 29066 49381 59851 9697 55683 49676 42054 26348 49078 47494 23941 68722 33402
78093 45205 22466 47983 84638 86716 81960 44243 38978 71632 23737 14216 69705 52169 39018 38550 29013 27614 0 62644 41329 12976 69313 58809 65555 36927 43863 30182 41348 12084 45135 53354 73904 32387 28017 54574 33377 34998 30810 29163 24669 19664 54886 41163
21166 18635 76241 78175 24028 74980 79501 21053 26905 33656 39294 53399 87002 32717 24408 91482 60191 69177 62644 0 21781 75558 18154 55326 48472 56131 26460 35043 33559 51195 50965 68088 78562 65457 77503 16289 32261 28433 42974 39341 46108 47545 18608 39282
40388 9920 54588 65452 43390 77036 78274 5402 7270 43330 17675 31625 79194 31045 8815 70120 44712 52310 41329 21781 0 54303 29402 51702 49800 43653 18989 17731 17523 30448 42410 59327 74580 45522 59458 20493 11123 10788 28415 18393 24945 28751 16595 30127
90523 57913 21028 48598 97610 93191 87007 57180 51840 82693 36707 25700 71318 62952 51734 33607 34196 28884 12976 75558 54303 0 82166 66222 74488 43377 55769 42394 53505 24560 52897 57575 78105 38044 24781 66914 46182 47681 41087 41489 36269 30776 67687 51307
34965 32082 78107 93131 18025 93129 97553 25307 30335 51405 45810 57379 103904 50185 36134 91560 73438 81516 69313 18154 29402 82166 0 72910 66576 70955 41951 46628 31182 59350 67228 84542 96272 63183 88853 33789 35990 39478 56464 41468 47875 58145 14566 55007
52479 43320 81172 33577 76833 28247 26720 56888 57729 31960 52182 62485 32762 22725 43010 97347 32362 40570 58809 55326 51702 66222 72910 0 12504 22923 32948 38992 68022 49737 13685 17699 23413 84669 49781 39156 57486 42677 30325 61928 64930 40960 65836 22493
41945 40315 87355 45999 68013 27383 31087 54500 56598 20403 54482 66752 43823 18758 41808 103877 41945 51025 65555 48472 49800 74488 66576 12504 0 32556 31028 40980 67100 55127 22182 30201 31642 88074 60555 33289 57608 42669 35062 63146 67260 46377 61767 25109
61664 38883 59384 22185 80001 50022 45104 48996 47472 46187 36699 43230 37019 27994 35339 75175 9492 18573 36927 56131 43653 43377 70955 22923 32556 0 29671 26440 56396 29768 10409 17809 37555 65694 28225 40938 45065 32895 15368 47237 48275 21861 60064 17206
35340 10482 62745 51750 50378 58114 59368 24017 25591 28729 25697 39933 62190 12357 10785 79286 34213 43633 43863 26460 18989 55769 41951 32948 31028 29671 0 13815 36089 31814 25316 42640 56124 59170 52544 12095 26771 12232 17347 32828 37801 25122 33084 13068
48144 16443 49051 47868 58713 66548 65541 22888 21035 42323 13517 26589 62941 24019 10790 65642 26991 34912 30182 35043 17731 42394 46628 38992 40980 26440 13815 0 30298 18100 27292 43134 60334 47134 42670 24522 19110 7150 11080 22937 26337 12562 34326 16592
54441 27423 47031 76817 48779 94176 94737 13961 10501 60513 21089 27838 93190 48359 25353 60443 54930 60446 41348 33559 17523 53505 31182 68022 67100 56396 36089 30298 0 34142 57387 73420 90278 32595 65580 37553 11336 25364 41229 12221 17469 36918 17278 45729
66063 33357 32294 45737 73805 77968 74472 34044 29390 59825 13766 13493 65562 40594 27183 48902 24172 27091 12084 51195 30448 24560 59350 49737 55127 29768 31814 18100 34142 0 36174 47383 67306 35927 31443 42511 24296 23124 20069 22275 20451 8826 45288 30192
53535 35603 67487 28445 74230 41821 38829 47796 47540 36481 39801 49125 36973 19838 33617 83680 19764 28967 45135 50965 42410 52897 67228 13685 22182 10409 25316 27292 57387 36174 0 17324 33050 71463 38592 34921 46354 32283 17368 49970 52317 27460 57980 12431
68673 52897 75538 16326 91027 37138 29504 64729 63989 49193 54324 60876 19879 36035 50609 90657 24342 29066 53354 68088 59327 57575 84542 17699 30201 17809 42640 43134 73420 47383 17324 0 20633 83281 36772 51903 62161 48887 32234 64826 66071 39665 75171 29697
73567 66571 96151 32663 99382 21178 10174 79863 80226 51807 72720 80734 15968 46111 65791 111280 44920 49381 73904 78562 74580 78105 96272 23413 31642 37555 56124 60334 90278 67306 33050 20633 0 103214 56298 62489 79353 64981 50209 82987 85029 59142 89147 44619
85634 54358 20645 80022 81082 112833 110152 44532 38924 87367 33741 22482 101033 70847 49537 29146 59569 59851 32387 65457 45522 38044 63183 84669 88074 65694 59170 47134 32595 35927 71463 83281 103214 0 60079 65436 34450 47002 54344 27262 21411 44176 49831 63213
87349 58887 45601 24670 101343 73858 65923 63929 60048 73985 44844 41409 47438 54870 53459 58038 19538 9697 28017 77503 59458 24781 88853 49781 60555 28225 52544 42670 65580 31443 38592 36772 56298 60079 0 64637 55407 49694 35200 53676 50962 30709 75516 42894
23632 11079 71876 62653 39341 60456 63969 23685 27729 22960 33779 48607 70716 16465 16489 88122 46166 55683 54574 16289 20493 66914 33789 39156 33289 40938 12095 24522 37553 42511 34921 51903 62489 65436 64637 0 31124 20063 29438 38190 44241 36677 28779 23765
51485 20098 44048 65557 52004 84279 84187 11568 5768 53565 10580 21731 81897 39107 16134 59245 43741 49676 33377 32261 11123 46182 35990 57486 57608 45065 26771 19110 11336 24296 46354 62161 79353 34450 55407 31124 0 15003 29939 7272 13908 25883 21512 35026
43230 10233 51852 54665 51834 69277 69397 16102 15268 40508 13920 28640 68765 24356 4074 68185 34083 42054 34998 28433 10788 47681 39478 42677 42669 32895 12232 7150 25364 23124 32283 48887 64981 47002 49694 20063 15003 0 17628 20643 25604 19223 27343 20380
52353 24744 52305 37047 66998 58512 56186 33713 32115 41691 22511 32240 51962 21898 20403 68860 17232 26348 30810 42974 28415 41087 56464 30325 35062 15368 17347 11080 41229 20069 17368 32234 50209 54344 35200 29438 29939 17628 0 32839 34949 11328 44937 10359
58752 27118 37269 66467 58186 89326 88460 18379 12465 60371 10544 16026 84253 44994 22593 52149 44449 49078 29163 39341 18393 41489 41468 61928 63146 47237 32828 22937 12221 22275 49970 64826 82987 27262 53676 38190 7272 20643 32839 0 6788 26312 26919 39486
65331 33178 30531 66034 64820 92784 91116 25167 19251 65966 12833 10603 85039 49629 28156 45375 44184 47494 24669 46108 24945 36269 47875 64930 67260 48275 37801 26337 17469 20451 52317 66071 85029 21411 50962 44241 13908 25604 34949 6788 0 26530 33392 42822
60279 28989 40980 40162 71057 69203 65987 33315 29854 52100 15961 21729 58510 32529 23162 57533 18148 23941 19664 47545 28751 30776 58145 40960 46377 21861 25122 12562 36918 8826 27460 39665 59142 44176 30709 36677 25883 19223 11328 26312 26530 0 44933 21596
39679 22603 63767 81983 31603 89093 91820 11624 15967 50451 31595 42814 94947 43651 24756 77642 61298 68722 54886 18608 16595 67687 14566 65836 61767 60064 33084 34326 17278 45288 57980 75171 89147 49831 75516 28779 21512 27343 44937 26919 33392 44933 0 45551
44837 23202 62486 38946 62977 49975 49166 35487 35608 31842 29995 41657 49404 12138 21313 79082 23521 33402 41163 39282 30127 51307 55007 22493 25109 17206 13068 16592 45729 30192 12431 29697 44619 63213 42894 23765 35026 20380 10359 39486 42822 21596 45551 0
colnames(jakarta_district_centroid_expanded)
 [1] "province"                 "city"                    
 [3] "district"                 "centroid_lat"            
 [5] "centroid_lng"             "Cultural_Attractions"    
 [7] "Essentials"               "Facilities_Services"     
 [9] "Offices_Business"         "Others"                  
[11] "Restaurants_Food"         "Shops"                   
[13] "Recreation_Entertainment" "Tourism_Spots"           
[15] "geometry"                 "centroid"                

6.1.1 Pivot Distance Value by Origin and Destination District

distPair <- melt(dist_districts) %>%
  rename(dist = value)
head(distPair, 10)
               Var1   Var2     dist
1            cakung cakung     0.00
2     cempaka putih cakung 33262.49
3        cengkareng cakung 94446.12
4          cilandak cakung 81967.21
5         cilincing cakung 28291.84
6          cipayung cakung 64521.55
7           ciracas cakung 71798.66
8      danau sunter cakung 41185.87
9  danau sunter dll cakung 46710.36
10      duren sawit cakung 21978.34

There are intrazonal distances with 0 values (interzones)

Before proceeding, we update them with the lowest non-zero distance values before applying log transformation given that log 0 is an undefined value

distPair %>% 
  filter(dist > 0) %>% 
  summary()
            Var1                 Var2           dist       
 cakung       :  43   cakung       :  43   Min.   :  4074  
 cempaka putih:  43   cempaka putih:  43   1st Qu.: 28445  
 cengkareng   :  43   cengkareng   :  43   Median : 44576  
 cilandak     :  43   cilandak     :  43   Mean   : 46934  
 cilincing    :  43   cilincing    :  43   3rd Qu.: 62941  
 cipayung     :  43   cipayung     :  43   Max.   :125149  
 (Other)      :1634   (Other)      :1634                   

The lowest minimum distance is 4074 m, any distance less than this can represent the intrazonal distance. For consistency and ease of remberance, we stick to 2000m as the intra-zonal distance

distPair$dist <- ifelse (distPair$dist == 0, 
                         2000, distPair$dist) 
distPair <- distPair %>% 
  rename(origin=Var1, 
         destination =Var2) %>% 
  mutate(across(c(origin, destination), as.factor))

summary(distPair)
           origin            destination        dist       
 cakung       :  44   cakung       :  44   Min.   :  2000  
 cempaka putih:  44   cempaka putih:  44   1st Qu.: 27330  
 cengkareng   :  44   cengkareng   :  44   Median : 43652  
 cilandak     :  44   cilandak     :  44   Mean   : 45912  
 cilincing    :  44   cilincing    :  44   3rd Qu.: 62485  
 cipayung     :  44   cipayung     :  44   Max.   :125149  
 (Other)      :1672   (Other)      :1672                   

6.2 Applying Log Transformation to Variables of Interest

We apply log transformation for the variables of interest (categories of POI for push and pull factors), to use Poisson Regresion method.

  1. For the subset of POIs in Origin and Destination Districts, we rename accordingly
poi_aggregated_origin <- poi_aggregated %>%
  rename(origin = district)

# Rename district to 'destination' for merging with destination data
poi_aggregated_destination <- poi_aggregated %>%
  rename(destination = district)
  1. We merge the distPair with POI data for both origin and destination subsets
  2. We Pivot the POI data wide to separate the categories into individual columns & counts
dist_with_POI <- distPair %>%
  left_join(poi_aggregated_origin, by = "origin") %>%
  left_join(poi_aggregated_destination, by = "destination", suffix = c("_origin", "_destination"))

poi_origin_wide <- dist_with_POI %>%
  pivot_wider(
    names_from = category_origin,
    values_from = count_origin,
    names_prefix = "origin_",
    values_fill = list(count_origin = 0)
  )

poi_orig_dest_wide <- poi_origin_wide %>%
  pivot_wider(
    names_from = category_destination,
    values_from = count_destination,
    names_prefix = "destination_",
    values_fill = list(count_destination = 0)
  )
  1. We apply Log Transformation to the category columns
columns_to_log_transform <- colnames(poi_orig_dest_wide)[4:19]


poi_orig_dest_wide[columns_to_log_transform] <- lapply(
  poi_orig_dest_wide[columns_to_log_transform],
  function(x) log(as.numeric(as.character(x)) + 1)
)

head(poi_orig_dest_wide[columns_to_log_transform])
# A tibble: 6 × 16
  origin_Cultural_Attractions origin_Essentials origin_Facilities_Services
                        <dbl>             <dbl>                      <dbl>
1                       1.10               2.56                       3.14
2                       0.693              2.20                       2.56
3                       2.30               3.95                       4.23
4                       1.61               3.26                       4.04
5                       1.95               3.43                       3.71
6                       1.10               1.61                       3.00
# ℹ 13 more variables: origin_Offices_Business <dbl>, origin_Others <dbl>,
#   origin_Restaurants_Food <dbl>, origin_Shops <dbl>,
#   origin_Recreation_Entertainment <dbl>,
#   destination_Cultural_Attractions <dbl>, destination_Essentials <dbl>,
#   destination_Facilities_Services <dbl>, destination_Offices_Business <dbl>,
#   destination_Others <dbl>, destination_Restaurants_Food <dbl>,
#   destination_Shops <dbl>, destination_Recreation_Entertainment <dbl>

6.3 Origin-Constrained Spatial Interaction Model

The origin-constrained model focuses on constraints at the origin locations, meaning the sum of interactions originating from each origin equals a known total, such as the total number of trips from each origin district. Here, only pull (attractive) factors of the destination districts will be used.

Before doing so, we merge od_data and the newly created poi_orig_dest_wide

od_data <- od_data %>%
  rename(
    origin = origin_district,
    destination = destination_district
  )

tripsData <- left_join(od_data, poi_orig_dest_wide, by = c("origin", "destination"))

head(tripsData)
# A tibble: 6 × 27
  origin           destination origin_time_cluster origin_weather_descr…¹ trj_id
  <chr>            <chr>       <chr>               <chr>                  <chr> 
1 kelapa gading    pasar rebo  morning lull        not_rain               10003 
2 setia budi       pasar ming… morning peak        rain                   10043 
3 makasar          duren sawit morning peak        rain                   10061 
4 setia budi       mampang pr… morning peak        not_rain               10072 
5 kebayoran lama   pasar ming… morning lull        not_rain               10086 
6 grogol petambur… tanah abang midnight lull       not_rain               10089 
# ℹ abbreviated name: ¹​origin_weather_description_category
# ℹ 22 more variables: driving_mode <chr>, centroid_lat_origin <dbl>,
#   centroid_lng_origin <dbl>, centroid_lat_destination <dbl>,
#   centroid_lng_destination <dbl>, dist <dbl>,
#   origin_Cultural_Attractions <dbl>, origin_Essentials <dbl>,
#   origin_Facilities_Services <dbl>, origin_Offices_Business <dbl>,
#   origin_Others <dbl>, origin_Restaurants_Food <dbl>, origin_Shops <dbl>, …
tripsData_counts <- tripsData %>%
  group_by(origin, destination) %>%
  summarise(trips_count = n_distinct(trj_id), .groups = 'drop')  # Count unique trips

tripsData <- left_join(tripsData, tripsData_counts, by = c("origin", "destination"))

*For better viewing of variables in the 3 SIMs, we rename the origin and destination variables to read as such : “origin_district” or “deestination_district”

tripsData <- tripsData %>%
  rename(
    origin_ = origin,
    destination_ = destination
  )
origSIM <- glm(
  trips_count ~ origin_ + destination_Cultural_Attractions + destination_Essentials + 
                destination_Facilities_Services + destination_Offices_Business + 
                destination_Others + destination_Restaurants_Food + 
                destination_Shops + destination_Recreation_Entertainment + 
                dist - 1,  # No intercept
  family = poisson(link = "log"),
  data = tripsData,
  na.action = na.exclude
)


summary(origSIM)

Call:
glm(formula = trips_count ~ origin_ + destination_Cultural_Attractions + 
    destination_Essentials + destination_Facilities_Services + 
    destination_Offices_Business + destination_Others + destination_Restaurants_Food + 
    destination_Shops + destination_Recreation_Entertainment + 
    dist - 1, family = poisson(link = "log"), data = tripsData, 
    na.action = na.exclude)

Coefficients:
                                       Estimate Std. Error z value Pr(>|z|)    
origin_cakung                         1.757e+00  9.602e-02  18.299  < 2e-16 ***
origin_cempaka putih                  1.151e+00  8.098e-02  14.215  < 2e-16 ***
origin_cengkareng                     2.000e+00  6.744e-02  29.656  < 2e-16 ***
origin_cilandak                       1.824e+00  7.585e-02  24.048  < 2e-16 ***
origin_cilincing                      1.882e+00  1.104e-01  17.047  < 2e-16 ***
origin_cipayung                       2.171e+00  8.077e-02  26.883  < 2e-16 ***
origin_ciracas                        2.030e+00  7.427e-02  27.330  < 2e-16 ***
origin_danau sunter dll               8.483e-01  3.812e-01   2.225  0.02608 *  
origin_duren sawit                    2.455e+00  6.651e-02  36.915  < 2e-16 ***
origin_gambir                         1.463e+00  6.598e-02  22.168  < 2e-16 ***
origin_grogol petamburan              2.258e+00  5.847e-02  38.611  < 2e-16 ***
origin_jagakarsa                      2.312e+00  7.264e-02  31.827  < 2e-16 ***
origin_jatinegara                     1.918e+00  6.603e-02  29.041  < 2e-16 ***
origin_johar baru                     7.807e-01  1.168e-01   6.685 2.31e-11 ***
origin_kali deres                     1.832e+00  9.023e-02  20.305  < 2e-16 ***
origin_kebayoran baru                 2.257e+00  5.870e-02  38.456  < 2e-16 ***
origin_kebayoran lama                 2.055e+00  6.228e-02  32.996  < 2e-16 ***
origin_kebon jeruk                    2.072e+00  6.038e-02  34.309  < 2e-16 ***
origin_kelapa gading                  2.236e+00  6.263e-02  35.696  < 2e-16 ***
origin_kemayoran                      1.319e+00  7.927e-02  16.640  < 2e-16 ***
origin_kembangan                      2.002e+00  6.569e-02  30.479  < 2e-16 ***
origin_koja                           1.533e+00  9.134e-02  16.778  < 2e-16 ***
origin_kramat jati                    1.777e+00  7.280e-02  24.411  < 2e-16 ***
origin_makasar                        1.471e+00  1.023e-01  14.372  < 2e-16 ***
origin_mampang prapatan               1.117e+00  8.251e-02  13.535  < 2e-16 ***
origin_matraman                       9.069e-01  1.015e-01   8.931  < 2e-16 ***
origin_menteng                        1.415e+00  6.699e-02  21.116  < 2e-16 ***
origin_pademangan                     1.701e+00  7.487e-02  22.720  < 2e-16 ***
origin_palmerah                       1.292e+00  7.098e-02  18.204  < 2e-16 ***
origin_pancoran                       1.490e+00  7.305e-02  20.402  < 2e-16 ***
origin_pasar minggu                   2.458e+00  6.085e-02  40.394  < 2e-16 ***
origin_pasar rebo                     1.225e+00  1.114e-01  10.996  < 2e-16 ***
origin_penjaringan                    2.339e+00  6.396e-02  36.562  < 2e-16 ***
origin_pesanggrahan                   1.184e+00  9.893e-02  11.965  < 2e-16 ***
origin_pulo gadung                    1.696e+00  7.214e-02  23.506  < 2e-16 ***
origin_sawah besar                    1.216e+00  7.618e-02  15.961  < 2e-16 ***
origin_senen                          1.330e+00  7.272e-02  18.288  < 2e-16 ***
origin_setia budi                     2.202e+00  5.860e-02  37.582  < 2e-16 ***
origin_taman sari                     1.508e+00  7.130e-02  21.148  < 2e-16 ***
origin_tambora                        1.386e+00  7.208e-02  19.223  < 2e-16 ***
origin_tanah abang                    2.185e+00  5.996e-02  36.440  < 2e-16 ***
origin_tanjung priok                  2.142e+00  6.210e-02  34.482  < 2e-16 ***
origin_tebet                          2.022e+00  6.135e-02  32.952  < 2e-16 ***
destination_Cultural_Attractions      3.063e-02  1.501e-02   2.041  0.04120 *  
destination_Essentials                2.289e-01  1.813e-02  12.625  < 2e-16 ***
destination_Facilities_Services       6.091e-03  2.009e-02   0.303  0.76171    
destination_Offices_Business         -1.335e-02  7.795e-03  -1.713  0.08669 .  
destination_Others                    3.566e-02  1.249e-02   2.854  0.00431 ** 
destination_Restaurants_Food          9.069e-02  1.280e-02   7.084 1.40e-12 ***
destination_Shops                     9.246e-02  2.175e-02   4.252 2.12e-05 ***
destination_Recreation_Entertainment  9.041e-02  1.106e-02   8.173 3.02e-16 ***
dist                                 -4.832e-05  6.542e-07 -73.851  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for poisson family taken to be 1)

    Null deviance: 107406.8  on 3759  degrees of freedom
Residual deviance:   5523.5  on 3707  degrees of freedom
AIC: 19515

Number of Fisher Scoring iterations: 5

6.4 Destination-Constrained Spatial Interaction Model

In this model, we focus on the characteristics of origins that make them less attractive or less likely to generate demand for trips to destinations. Unlike the origin-constrained model, which emphasizes limitations or capacities at the origin, the destination-constrained model highlights the demand (attractiveness) of destinations by analyzing factors that make certain origins less likely to send trips.

destSIM <- glm(
  trips_count ~ destination_ + origin_Cultural_Attractions + origin_Essentials + 
                origin_Facilities_Services + origin_Offices_Business + 
                origin_Others + origin_Restaurants_Food + 
                origin_Shops + origin_Recreation_Entertainment + 
                dist - 1,  # No intercept
  family = poisson(link = "log"),
  data = tripsData,
  na.action = na.exclude
)

summary(destSIM)

Call:
glm(formula = trips_count ~ destination_ + origin_Cultural_Attractions + 
    origin_Essentials + origin_Facilities_Services + origin_Offices_Business + 
    origin_Others + origin_Restaurants_Food + origin_Shops + 
    origin_Recreation_Entertainment + dist - 1, family = poisson(link = "log"), 
    data = tripsData, na.action = na.exclude)

Coefficients:
                                  Estimate Std. Error z value Pr(>|z|)    
destination_cakung               2.360e+00  6.860e-02  34.397  < 2e-16 ***
destination_cempaka putih        1.357e+00  8.843e-02  15.350  < 2e-16 ***
destination_cengkareng           2.207e+00  6.253e-02  35.295  < 2e-16 ***
destination_cilandak             2.290e+00  6.641e-02  34.489  < 2e-16 ***
destination_cilincing            1.943e+00  9.876e-02  19.670  < 2e-16 ***
destination_cipayung             2.039e+00  9.427e-02  21.632  < 2e-16 ***
destination_ciracas              2.001e+00  8.865e-02  22.567  < 2e-16 ***
destination_danau sunter         4.959e-01  5.023e-01   0.987 0.323475    
destination_danau sunter dll    -7.051e-01  7.092e-01  -0.994 0.320111    
destination_duren sawit          2.339e+00  7.313e-02  31.979  < 2e-16 ***
destination_gambir               1.786e+00  5.935e-02  30.099  < 2e-16 ***
destination_grogol petamburan    2.003e+00  5.969e-02  33.561  < 2e-16 ***
destination_jagakarsa            2.755e+00  6.507e-02  42.342  < 2e-16 ***
destination_jatinegara           2.225e+00  6.151e-02  36.175  < 2e-16 ***
destination_johar baru           6.419e-01  1.802e-01   3.562 0.000368 ***
destination_kali deres           1.552e+00  1.013e-01  15.331  < 2e-16 ***
destination_kebayoran baru       2.421e+00  5.782e-02  41.868  < 2e-16 ***
destination_kebayoran lama       2.257e+00  6.043e-02  37.349  < 2e-16 ***
destination_kebon jeruk          1.871e+00  6.318e-02  29.620  < 2e-16 ***
destination_kelapa gading        1.840e+00  7.335e-02  25.080  < 2e-16 ***
destination_kemayoran            1.374e+00  7.707e-02  17.832  < 2e-16 ***
destination_kembangan            1.969e+00  6.540e-02  30.108  < 2e-16 ***
destination_koja                 1.853e+00  7.710e-02  24.032  < 2e-16 ***
destination_kramat jati          2.166e+00  6.987e-02  31.002  < 2e-16 ***
destination_makasar              1.732e+00  9.221e-02  18.778  < 2e-16 ***
destination_mampang prapatan     1.776e+00  6.621e-02  26.819  < 2e-16 ***
destination_matraman             1.232e+00  9.030e-02  13.641  < 2e-16 ***
destination_menteng              1.733e+00  6.134e-02  28.247  < 2e-16 ***
destination_pademangan           1.799e+00  6.963e-02  25.836  < 2e-16 ***
destination_palmerah             1.571e+00  6.456e-02  24.332  < 2e-16 ***
destination_pancoran             1.757e+00  6.936e-02  25.334  < 2e-16 ***
destination_pasar minggu         2.258e+00  6.494e-02  34.767  < 2e-16 ***
destination_pasar rebo           1.745e+00  8.416e-02  20.736  < 2e-16 ***
destination_penjaringan          2.482e+00  6.163e-02  40.267  < 2e-16 ***
destination_pesanggrahan         1.680e+00  7.868e-02  21.357  < 2e-16 ***
destination_pulo gadung          1.816e+00  6.962e-02  26.087  < 2e-16 ***
destination_sawah besar          1.329e+00  8.095e-02  16.419  < 2e-16 ***
destination_senen                1.324e+00  7.136e-02  18.549  < 2e-16 ***
destination_setia budi           2.253e+00  5.661e-02  39.790  < 2e-16 ***
destination_taman sari           9.732e-01  8.797e-02  11.063  < 2e-16 ***
destination_tambora              1.150e+00  8.140e-02  14.127  < 2e-16 ***
destination_tanah abang          2.218e+00  5.859e-02  37.859  < 2e-16 ***
destination_tanjung priok        2.137e+00  6.045e-02  35.354  < 2e-16 ***
destination_tebet                2.033e+00  6.064e-02  33.530  < 2e-16 ***
origin_Cultural_Attractions      5.248e-02  1.516e-02   3.462 0.000535 ***
origin_Essentials                3.353e-01  1.862e-02  18.007  < 2e-16 ***
origin_Facilities_Services      -1.772e-01  1.889e-02  -9.381  < 2e-16 ***
origin_Offices_Business          8.820e-02  8.129e-03  10.850  < 2e-16 ***
origin_Others                    1.025e-01  1.253e-02   8.178 2.89e-16 ***
origin_Restaurants_Food          8.175e-02  1.318e-02   6.201 5.61e-10 ***
origin_Shops                    -1.214e-02  2.078e-02  -0.584 0.559137    
origin_Recreation_Entertainment  3.943e-02  1.144e-02   3.446 0.000570 ***
dist                            -4.997e-05  6.669e-07 -74.927  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for poisson family taken to be 1)

    Null deviance: 107406.8  on 3759  degrees of freedom
Residual deviance:   5592.6  on 3706  degrees of freedom
AIC: 19586

Number of Fisher Scoring iterations: 5

6.5 Doubly Constrained Spatial Interaction Model.

The Doubly Constrained Spatial Interaction Model considers both the influence of origins and destinations on trip flows, balancing “push” factors from origins and “pull” factors from destinations.

dbcSIM <- glm(formula = trips_count ~ origin_ + destination_ + dist, 
              family = poisson(link = "log"),
  data = tripsData,
  na.action = na.exclude)

summary(dbcSIM)

Call:
glm(formula = trips_count ~ origin_ + destination_ + dist, family = poisson(link = "log"), 
    data = tripsData, na.action = na.exclude)

Coefficients:
                                Estimate Std. Error z value Pr(>|z|)    
(Intercept)                    3.203e+00  9.601e-02  33.363  < 2e-16 ***
origin_cempaka putih          -4.075e-01  1.003e-01  -4.063 4.85e-05 ***
origin_cengkareng              3.106e-01  9.213e-02   3.371 0.000749 ***
origin_cilandak               -1.345e-01  9.759e-02  -1.378 0.168241    
origin_cilincing               1.114e-02  1.292e-01   0.086 0.931284    
origin_cipayung                3.830e-01  1.097e-01   3.493 0.000477 ***
origin_ciracas                 4.487e-02  1.020e-01   0.440 0.660069    
origin_danau sunter dll       -8.396e-01  3.870e-01  -2.170 0.030042 *  
origin_duren sawit             5.814e-01  8.933e-02   6.508 7.62e-11 ***
origin_gambir                 -4.428e-02  9.000e-02  -0.492 0.622735    
origin_grogol petamburan       6.695e-01  8.533e-02   7.846 4.28e-15 ***
origin_jagakarsa               4.331e-01  9.754e-02   4.440 9.00e-06 ***
origin_jatinegara              1.157e-01  8.872e-02   1.304 0.192394    
origin_johar baru             -7.352e-01  1.313e-01  -5.601 2.14e-08 ***
origin_kali deres             -4.729e-03  1.120e-01  -0.042 0.966326    
origin_kebayoran baru          4.409e-01  8.557e-02   5.152 2.58e-07 ***
origin_kebayoran lama          2.332e-01  8.804e-02   2.649 0.008083 ** 
origin_kebon jeruk             3.285e-01  8.797e-02   3.735 0.000188 ***
origin_kelapa gading           5.258e-01  8.776e-02   5.992 2.08e-09 ***
origin_kemayoran              -3.082e-01  9.898e-02  -3.114 0.001848 ** 
origin_kembangan               3.949e-01  9.045e-02   4.365 1.27e-05 ***
origin_koja                   -2.494e-01  1.122e-01  -2.224 0.026149 *  
origin_kramat jati            -3.451e-02  9.452e-02  -0.365 0.715013    
origin_makasar                -4.576e-01  1.200e-01  -3.813 0.000137 ***
origin_mampang prapatan       -7.178e-01  1.022e-01  -7.020 2.22e-12 ***
origin_matraman               -8.298e-01  1.167e-01  -7.111 1.15e-12 ***
origin_menteng                -2.166e-01  8.986e-02  -2.410 0.015950 *  
origin_pademangan              1.247e-01  9.598e-02   1.299 0.193935    
origin_palmerah               -3.711e-01  9.318e-02  -3.982 6.83e-05 ***
origin_pancoran               -3.307e-01  9.426e-02  -3.508 0.000451 ***
origin_pasar minggu            5.254e-01  8.826e-02   5.953 2.64e-09 ***
origin_pasar rebo             -7.715e-01  1.299e-01  -5.939 2.86e-09 ***
origin_penjaringan             8.009e-01  8.878e-02   9.021  < 2e-16 ***
origin_pesanggrahan           -7.365e-01  1.179e-01  -6.246 4.21e-10 ***
origin_pulo gadung            -3.143e-02  9.417e-02  -0.334 0.738523    
origin_sawah besar            -3.119e-01  9.701e-02  -3.215 0.001305 ** 
origin_senen                  -2.733e-01  9.313e-02  -2.935 0.003338 ** 
origin_setia budi              4.776e-01  8.446e-02   5.654 1.57e-08 ***
origin_taman sari             -7.840e-02  9.273e-02  -0.845 0.397838    
origin_tambora                -1.637e-01  9.455e-02  -1.731 0.083468 .  
origin_tanah abang             5.135e-01  8.464e-02   6.067 1.31e-09 ***
origin_tanjung priok           6.098e-01  8.885e-02   6.864 6.71e-12 ***
origin_tebet                   2.474e-01  8.552e-02   2.892 0.003823 ** 
destination_cempaka putih     -7.762e-01  8.643e-02  -8.981  < 2e-16 ***
destination_cengkareng         1.315e-01  6.191e-02   2.124 0.033645 *  
destination_cilandak           3.037e-02  6.413e-02   0.474 0.635828    
destination_cilincing         -3.308e-01  9.922e-02  -3.334 0.000857 ***
destination_cipayung          -2.144e-01  9.910e-02  -2.163 0.030535 *  
destination_ciracas           -5.476e-01  1.005e-01  -5.450 5.03e-08 ***
destination_danau sunter      -1.929e+00  5.029e-01  -3.836 0.000125 ***
destination_danau sunter dll  -2.753e+00  7.094e-01  -3.881 0.000104 ***
destination_duren sawit        1.093e-01  7.084e-02   1.543 0.122824    
destination_gambir            -1.544e-01  5.745e-02  -2.687 0.007208 ** 
destination_grogol petamburan  8.412e-02  5.837e-02   1.441 0.149552    
destination_jagakarsa          4.718e-01  6.575e-02   7.176 7.18e-13 ***
destination_jatinegara         2.465e-02  5.600e-02   0.440 0.659860    
destination_johar baru        -1.332e+00  1.785e-01  -7.461 8.58e-14 ***
destination_kali deres        -5.012e-01  1.013e-01  -4.948 7.49e-07 ***
destination_kebayoran baru     3.172e-01  5.590e-02   5.674 1.39e-08 ***
destination_kebayoran lama     2.051e-01  5.870e-02   3.495 0.000474 ***
destination_kebon jeruk       -2.282e-01  6.206e-02  -3.677 0.000236 ***
destination_kelapa gading     -3.097e-01  7.089e-02  -4.369 1.25e-05 ***
destination_kemayoran         -7.134e-01  7.362e-02  -9.690  < 2e-16 ***
destination_kembangan          1.620e-02  6.478e-02   0.250 0.802565    
destination_koja              -5.040e-01  7.637e-02  -6.600 4.12e-11 ***
destination_kramat jati       -1.293e-01  6.887e-02  -1.877 0.060535 .  
destination_makasar           -6.126e-01  9.286e-02  -6.597 4.21e-11 ***
destination_mampang prapatan  -3.856e-01  6.304e-02  -6.116 9.58e-10 ***
destination_matraman          -9.475e-01  8.734e-02 -10.849  < 2e-16 ***
destination_menteng           -2.853e-01  5.918e-02  -4.820 1.43e-06 ***
destination_pademangan        -2.906e-01  6.688e-02  -4.346 1.39e-05 ***
destination_palmerah          -4.769e-01  6.192e-02  -7.702 1.34e-14 ***
destination_pancoran          -4.289e-01  6.653e-02  -6.446 1.15e-10 ***
destination_pasar minggu       1.456e-01  6.521e-02   2.232 0.025601 *  
destination_pasar rebo        -6.262e-01  8.607e-02  -7.276 3.44e-13 ***
destination_penjaringan        5.179e-01  5.917e-02   8.752  < 2e-16 ***
destination_pesanggrahan      -5.033e-01  7.834e-02  -6.425 1.32e-10 ***
destination_pulo gadung       -4.349e-01  6.679e-02  -6.511 7.45e-11 ***
destination_sawah besar       -7.267e-01  7.841e-02  -9.267  < 2e-16 ***
destination_senen             -6.100e-01  6.843e-02  -8.915  < 2e-16 ***
destination_setia budi         2.413e-01  5.463e-02   4.417 9.99e-06 ***
destination_taman sari        -9.770e-01  8.563e-02 -11.410  < 2e-16 ***
destination_tambora           -8.445e-01  7.844e-02 -10.767  < 2e-16 ***
destination_tanah abang        2.175e-01  5.464e-02   3.982 6.84e-05 ***
destination_tanjung priok      7.956e-02  6.002e-02   1.326 0.184978    
destination_tebet             -4.156e-02  5.709e-02  -0.728 0.466646    
dist                          -5.258e-05  6.921e-07 -75.971  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for poisson family taken to be 1)

    Null deviance: 20530  on 3758  degrees of freedom
Residual deviance:  3537  on 3672  degrees of freedom
AIC: 17598

Number of Fisher Scoring iterations: 5

7.0 Push-Pull Factor Analysis

In our prototyping and hypothesis, there were discussions on whether the number of category counts affected the push and pull factors of that particular district. For example, if a district had was populated with restaurants_and_food POIs as a category, compared to the origin district, we can theorize that residents or tourists might be going there because of said Point of Interest. While we recognize correlation is not causation, we will thus perform push-pull factor analysis to see the distribution and likely reasons why Grab demand might be generated from origin to destination.

7.1 Bar Chart of Coefficients

Steps: 1. We combine coefficients from models into a data frame

origin_constrained_coef <- coef(origSIM)[c("destination_Cultural_Attractions", "destination_Essentials", 
                                           "destination_Facilities_Services", "destination_Offices_Business", 
                                           "destination_Others", "destination_Restaurants_Food", 
                                           "destination_Shops", "destination_Recreation_Entertainment")]

destination_constrained_coef <- coef(destSIM)[c("origin_Cultural_Attractions", "origin_Essentials", 
                                                "origin_Facilities_Services", "origin_Offices_Business", 
                                                "origin_Others", "origin_Restaurants_Food", 
                                                "origin_Shops", "origin_Recreation_Entertainment")]

doubly_constrained_coef <- coef(dbcSIM)[c("origin_Cultural_Attractions", "origin_Essentials", 
                                          "origin_Facilities_Services", "origin_Offices_Business", 
                                          "origin_Others", "origin_Restaurants_Food", 
                                          "origin_Shops", "origin_Recreation_Entertainment")]

coefficients_df <- data.frame(
  Category = c("Cultural_Attractions", "Essentials", "Facilities_Services", 
               "Offices_Business", "Others", "Restaurants_Food", 
               "Shops", "Recreation_Entertainment"),
  Origin_Constrained = origin_constrained_coef,
  Destination_Constrained = destination_constrained_coef,
  Doubly_Constrained = doubly_constrained_coef
)
  1. We melt the data for ggplot and then plot the coefficients
coefficients_long <- coefficients_df %>%
  pivot_longer(cols = -Category, names_to = "Model", values_to = "Coefficient")

# Plot
ggplot(coefficients_long, aes(x = Category, y = Coefficient, fill = Model)) +
  geom_bar(stat = "identity", position = "dodge") +
  theme_minimal() +
  labs(title = "Push-Pull Factor Coefficients by Model",
       x = "POI Category",
       y = "Coefficient")

  1. We filter data for each model
origin_constrained_data <- coefficients_long %>% filter(Model == "Origin_Constrained")
destination_constrained_data <- coefficients_long %>% filter(Model == "Destination_Constrained")

num_categories <- length(unique(coefficients_long$Category))
category_colors <- brewer.pal(min(num_categories, 9), "Pastel1")
if (num_categories > 9) {
  category_colors <- colorRampPalette(brewer.pal(9, "Pastel1"))(num_categories)
}
  1. Create individual plots for the model. For ease of visualization we rotate the axis and have a centered 0 axis.
plot_origin <- ggplot(origin_constrained_data, aes(y = Category, x = Coefficient, fill = Category)) +
  geom_bar(stat = "identity") +
  theme_minimal() +
  labs(title = "Origin-Constrained Model",
       x = "Coefficient",
       y = "POI Category") +
  theme(axis.text.y = element_text(angle = 0, hjust = 1),
        legend.position = "none") +  # Remove legend to avoid repetition in the plots
  scale_x_continuous(position = "top", limits = c(-max(abs(origin_constrained_data$Coefficient)),
                                                  max(abs(origin_constrained_data$Coefficient)))) +
  scale_fill_manual(values = category_colors)

plot_destination <- ggplot(destination_constrained_data, aes(y = Category, x = Coefficient, fill = Category)) +
  geom_bar(stat = "identity") +
  theme_minimal() +
  labs(title = "Destination-Constrained Model",
       x = "Coefficient",
       y = "POI Category") +
  theme(axis.text.y = element_text(angle = 0, hjust = 1),
        legend.position = "none") +  # Remove legend for consistency
  scale_x_continuous(position = "top", limits = c(-max(abs(destination_constrained_data$Coefficient)),
                                                  max(abs(destination_constrained_data$Coefficient)))) +
  scale_fill_manual(values = category_colors)

# Arrange the individual plots in a single column
grid.arrange(plot_origin, plot_destination, ncol = 1)

Other than the comparative Bar Charts above which effectively plots the coefficient of the attractiveness and propulsiveness of the POI categories, we can also consider Chord Diagrams to display the connections between origin and destination POI categories 1. Summarize trips data to get total trips between each origin and destination 2. Clear Previous Plots

chord_data <- tripsData %>%
  group_by(origin_, destination_) %>%
  summarise(trips_count = sum(trips_count)) %>%
  ungroup()
`summarise()` has grouped output by 'origin_'. You can override using the
`.groups` argument.
category_colors <- colorRampPalette(c("lightblue", "lightpink", "lightgreen", "lightcoral", "lightyellow"))(length(unique(c(chord_data$origin_, chord_data$destination_))))


circos.clear()
  1. Plot Chord Diagram
chordDiagram(
  chord_data,
  grid.col = category_colors,        
  transparency = 0.3,                
  annotationTrack = "grid",           
  annotationTrackHeight = mm_h(5),    
  preAllocateTracks = list(track.height = 0.05)  #
)


circos.trackPlotRegion(track.index = 1, panel.fun = function(x, y) {
  sector.index <- get.cell.meta.data("sector.index")
  circos.text(CELL_META$xcenter, CELL_META$ylim[1] + mm_y(5), sector.index, facing = "clockwise", niceFacing = TRUE, adj = c(0, 0.5), cex = 0.8)
}, bg.border = NA)

title("Push-Pull Factors by Category in Chord Diagram", cex.main = 1.5)

head(top_districts)
[1] "setia budi"        "grogol petamburan" "kebayoran baru"   
[4] "penjaringan"       "tanah abang"      
head(poi_aggregated_top)
# A tibble: 6 × 3
  district          category                 count
  <chr>             <chr>                    <int>
1 grogol petamburan Cultural_Attractions         8
2 grogol petamburan Essentials                  59
3 grogol petamburan Facilities_Services        130
4 grogol petamburan Offices_Business            95
5 grogol petamburan Others                       4
6 grogol petamburan Recreation_Entertainment     6

8.0 Proposed Visualizations for OD Desire Line Maps

8.1 Additional Visualization for Trip Count by Time of Day (Heatmap)

To visualize the distribution of trips by time of day, we can use the code chunk below:

ORIGIN

tripsData$origin_time_cluster <- factor(
  tripsData$origin_time_cluster,
  levels = c("midnight peak", "midnight lull", "morning peak", "morning lull", 
             "afternoon peak", "afternoon lull", "evening peak", "evening lull")
)


ggplot(tripsData, aes(x = origin_time_cluster, y = origin_, fill = trips_count)) +
  geom_tile(color = "white") +  
  scale_fill_gradient(low = "white", high = "black", name = "Trip Count") +  
  scale_x_discrete(position = "top") +  
  labs(
    title = "Heatmap of Trips Throughout the Time of Day",
    x = "Time Cluster",
    y = "Destination District"
  ) +
  theme_void() +  
  theme(
    axis.text.x = element_text(angle = 0, hjust = 0.5),  
    axis.text.y = element_text(margin = margin(r = 5)), 
    plot.title = element_text(hjust = 0.5)  
  )

DESTINATION

tripsData$origin_time_cluster <- factor(
  tripsData$origin_time_cluster,
  levels = c("midnight peak", "midnight lull", "morning peak", "morning lull", 
             "afternoon peak", "afternoon lull", "evening peak", "evening lull")
)


ggplot(tripsData, aes(x = origin_time_cluster, y = destination_, fill = trips_count)) +
  geom_tile(color = "white") +  
  scale_fill_gradient(low = "white", high = "black", name = "Trip Count") +  # Grayscale palette
  scale_x_discrete(position = "top") +  
  labs(
    title = "Heatmap of Trips Throughout the Time of Day",
    x = "Time Cluster",
    y = "Destination District"
  ) +
  theme_void() +  # Removes all default grid elements
  theme(
    axis.text.x = element_text(angle = 0, hjust = 0.5),  
    axis.text.y = element_text(margin = margin(r = 5)),  
    plot.title = element_text(hjust = 0.5)  
  )

8.2 Split Plot Visualization for Weather Conditions:

  1. We create a central 0 line
  2. Represent ‘Not_Rain’ with Grey and ‘Rain’ with Blue
trip_counts_district <- tripsData %>%
  group_by(origin_, origin_weather_description_category) %>%
  summarise(trip_count = n(), .groups = "drop")


trip_counts_district <- trip_counts_district %>%
  mutate(trip_count = ifelse(origin_weather_description_category == "rain", -trip_count, trip_count))


ggplot(trip_counts_district, aes(y = origin_, x = trip_count, fill = origin_weather_description_category)) +
  geom_bar(stat = "identity", width = 0.8) +
  labs(
    title = "Trip Counts by Rain Condition per District",
    y = "District",
    x = "Trip Count",
    fill = "Weather Condition"
  ) +
  theme_minimal() +
  theme(
    axis.text.y = element_text(size = 8, margin = margin(r = 10)),  
    panel.grid = element_blank()                                 
  ) +
  scale_fill_manual(values = c("darkgrey", "blue")) +                 
  scale_x_continuous(labels = abs, limits = c(-max(trip_counts_district$trip_count), max(trip_counts_district$trip_count))) + geom_vline(xintercept = 0, color = "black") 

8.3 Shiny Prototype Sample: Time Cluster (with Vehicle Type)

8.4 Shiny Prototype Sample: Weather Condition with Split Plot Visualization

8.5 Shiny Prototype Sample: Points of Interest + Push and Pull Factor Analysis

Note

(To be Combined with Selected Desire Line Map Conditions)
For Prototyping Purposes we depict a singular point to see the tooltip and distribution of categories

For the Chord Diagram, Upon Clicking on a Specific District in the Radius, the user can visualize the flows of trips, in this Case District as Origin

9.0 Conclusion

In this project, I set out to understand the demand for Grab’s ride-hailing services in Jakarta by digging into spatial patterns and exploring how factors like time of day, weather, and vehicle type impact trip flows across the city. By analyzing demand patterns around specific Points of Interest (like restaurants, entertainment spots, and essential services), I found that these areas attract more trips during peak times and when it rains—insights that could help Grab, urban planners, and policymakers better manage the challenges of Jakarta’s traffic.

Geospatial Analysis, especially Spatial Interaction Modelling can show how smart data use can make city life smoother. By using visualizations like heatmaps and chord diagrams, I tried to break down complex mobility patterns into something more accessible. The insights here could help shape practical solutions for Jakarta’s traffic congestion, from adjusting service availability during high-demand times to making it easier for people to reach essential services without exacerbating traffic congestion. While this analysis may not offer direct solutions, it does offer insights based on complex geospatial variables.